D2. 표 3장 → 구조체 — Optional · Conditional · union · 정렬

쓰는 템플릿: 2. 데이터 모델 골격

목표 시간: 1회차 60분 / 2회차 35분 / 3회차 22분

여기서 구조체를 잘못 짜면 D4(pack) · D8(3GPP) 에서 전부 되돌아온다. 가장 비용이 큰 단계다.

이 드릴의 문서 구성 (D0~D9 공통)

① 요구사항서 — 무엇을 만드나, 표로 확정한다. ② pseudo code 템플릿 — 보고 치는 뼈대이고, 회차마다 가린다. ③ 테스트 코드 — 이게 통과하면 끝이다. ④ 자주 하는 실수 ⑤ 모범답안 — 맨 뒤에 접어 둔다.

변형 A/B/C 는 문제가 바뀌는 게 아니라 같은 모양을 다른 소재로 다시 치는 것이다. 각 변형마다 ①②③이 모두 준비되어 있다.


0. 이 드릴로 체화할 것

반사적으로 나와야 하는 것규칙
Optional 필드bool has_xxx; 를 값 바로 위에 붙인다
문자열char name[NAME_LEN + 1]; — +1 없이 쓰면 틀린 것
enumXXX_UNKNOWN = 0 으로 시작, XXX_END 로 끝. to_str/from_str 쌍으로
배열type arr[MAX]; uint8_t arr_num; — 개수 필드는 배열 바로 아래
구조체마다_init() · _validate() · (동적이면) _free() 3종 세트
P = C (조건부)has_ + 조건식을 함수로. 조건 참인데 없으면 에러, 거짓인데 왔으면 정한 정책대로
배타 C 여러 개tagged union — 태그 확인 후 읽기, 쓸 때는 태그 → memset → 대입
멤버 순서다 짠 뒤 정렬 큰 것 → 작은 것 (8 → 4 → 2 → 1), char[] 는 맨 끝. sizeof 로 확인

"0이 들어온 건가, 안 온 건가"

Optional 을 has_ 없이 값만 두면 이 질문에 답할 수 없다. ambr_ul = 0 이 “0 kbps 제한”인지 “규격에 없어서 안 왔다”인지 구분이 안 되는 순간, 그 구조체는 못 쓴다.

C 를 O 로 옮겨 적으면 검사가 통째로 빠진다

O 는 “없어도 정상”이라 validate 가 아무것도 안 한다. C 는 “어떤 경우엔 없으면 안 된다.” 표를 옮겨 적을 때 조건 열을 만들지 않으면, 이 차이가 코드에 도달하지 못한다.

순서는 두 번에 나눠서 잡는다

1차: 규격서 표 순서대로 (뜻을 놓치지 않으려고). has_ 는 값 바로 위. 2차: 다 짠 뒤 정렬 기준으로 재배치. 이때 has_ 는 1바이트 그룹으로 모은다. 1차에서 바로 정렬까지 하려 들면 필드를 빠뜨린다. 먼저 맞게, 그다음 작게. → 규칙 전체는 데이터 모델 골격 4장

회차별 진행

회차변형보는 것순서
1회차A 가입자 프로파일
(확장 포함)
① 요구사항서
② pseudo code 전체
1. C 판단표와 오프셋 손 계산표를 먼저 채운다.
2. pseudo 를 한 줄씩 코드로 바꾼다.
3. ③ 테스트를 실행한다.
2회차B 과금 정책 레코드
(비트마스크 12필드)
① 요구사항서
② pseudo code 의 번호와 제목만 (Level 2)
1. 판단표를 채운다.
2. 번호만 보고 쓴다.
3. ③ 테스트를 실행한다.
3회차C 로그 질의 결과
(동적 배열)
① 요구사항서만1. ③ 테스트를 먼저 읽는다.
2. 백지에서 친다.

1회차에 막히면 같은 변형을 한 번 더 친다. 3회차를 통과하면 A 를 백지로 다시 친다.


1. 요구사항서

1-A. 변형 A — 가입자 프로파일 레코드

요구사항서 (발췌)

가입자 프로파일 조회 응답을 담을 자료구조를 설계한다.

#필드타입P값 범위 / 형식설명
1supistringM숫자 15자 이하가입자 식별자
2nicknamestringOUTF-8 31바이트 이하표시 이름
3ratTypeenumONR / EUTRA / WLAN접속 무선 종류
4ambrUlintegerO0 ~ 4,000,000 (kbps)상향 최대 대역
5ambrDlintegerO0 ~ 4,000,000 (kbps)하향 최대 대역
6allowedNssaiarrayM1 ~ 8개허용 슬라이스 목록
6-1└ sstintegerM0 ~ 255슬라이스 종류
6-2└ sdstringO6자리 16진수슬라이스 구분자
7suspendedbooleanM기본값 false정지 여부
8lastSeenintegerOUNIX epoch (초)마지막 접속 시각

미션

  1. subs_type.h — 위 표를 구조체로. Optional 은 전부 has_ 플래그.
  2. rat_type_t enum + rat_type_to_str() / rat_type_from_str() 쌍.
  3. subs_profile_init(subs_profile_t *p) — 안전한 초기 상태로.
  4. subs_profile_validate(const subs_profile_t *p) — 표의 범위를 전부 검사. 위반 시 어느 필드인지 로그.
  5. subs_profile_dump(const subs_profile_t *p) — Optional 은 없으면 - 로 출력.
  6. 정렬 — 코드를 치기 전에 아래 2장의 “오프셋 손 계산표”로 1차 구조체의 sizeof 를 예측하고, 다 친 뒤 sizeof/offsetof 를 찍어 맞는지 확인. 그다음 멤버를 재배치해 패딩을 줄이고 _Static_assert 로 크기를 고정.

확장 요구사항 — 조건부(C) 필드 (같은 레코드에 이어서 붙인다)

요구사항서 (2차 개정 — 필드 9~12 추가)

#필드타입P조건 (P = C 일 때)값 범위 / 형식
9chargingMethodenumM—PREPAID / POSTPAID
10prepaidInfoobjectCchargingMethod == PREPAID아래 10-1
10-1└ balanceintegerM—0 ~ 40억 (원)
11postpaidInfoobjectCchargingMethod == POSTPAID아래 11-1, 11-2
11-1└ billingDayintegerM—1 ~ 28 (일)
11-2└ invoiceEmailsarrayO—0 ~ 3개, 각 63바이트 이하
12suspendCausestringCsuspended == true63바이트 이하

여기 C 가 세 개인데 성격이 다르다. 먼저 그것부터 가른다.

미션 (이어서)

  1. C 판단표부터 채운다 (코드 치기 전). 세 C 를 독립 / 배타 / 묶음 / 최소하나 중 무엇인지 분류.
  2. suspend_cause — C 가 하나뿐인 경우. has_ + 조건 검사. “조건 거짓인데 값이 왔다” 의 정책을 정하고 한 줄 근거를 적는다.
  3. prepaid / postpaid — 배타 C. charging_method 를 태그로 재사용하는 tagged union. subs_profile_get_postpaid() / subs_profile_set_postpaid() 쌍을 만들고, 구조체 바깥에서 info 를 직접 건드리는 코드가 없게 한다.
  4. 확장 때문에 6번의 _Static_assert 가 깨진다. 새 sizeof 를 다시 예측 → 실측 → 갱신. (깨지는 게 정상이다. 그게 _Static_assert 를 박아 둔 이유다.)

prepaidInfo 는 값이 한 개, postpaidInfo 는 여러 개다

이 둘을 union 에 나란히 넣을 수 있는 이유는 각자를 자기 구조체로 감쌌기 때문이다. 감싸고 나면 union 입장에선 둘 다 “구조체 하나” 라 똑같이 다룬다. 배열의 개수 필드(email_cnt)도 그 구조체 안에 넣는다 — union 밖에 두면 태그가 바뀔 때 엉뚱한 개수를 본다.

C 판단표 — 채우고 시작한다

필드조건다른 C 와 동시에 올 수 있나패턴표현
suspend_cause
prepaid_info
postpaid_info

여기서 union 을 잘못 고르면 에러 없이 데이터가 지워진다

union 멤버는 같은 메모리를 겹쳐 쓴다. 동시에 올 수 있는 필드를 union 에 넣으면 둘째를 채우는 순간 첫째가 조용히 망가진다. 컴파일도 되고 경고도 없다. “정확히 하나만 존재한다” 고 규격이 보장할 때만 union 이다. → 분류 기준 전체는 데이터 모델 골격 2-8

에러 코드 규칙 (A)

상황리턴코드로그
p 가 NULLRET_INVALID_ARG가드가 남긴다
표의 범위·개수 위반 (nssai_num 0 / 9, ambr 초과 등)RET_BAD_FORMATO (어느 필드인지)
조건 참인데 C 필드가 없음 (suspended 인데 suspend_cause 없음)RET_INVALID_ARGO
from_str 에 NULLRET_INVALID_ARG가드가 남긴다
from_str 에 모르는 문자열RET_OK + RAT_UNKNOWNWRN (규격에 값이 추가돼도 죽지 않게)

1-B. 변형 B — 과금 정책 레코드

has_ 12개를 비트마스크 하나로 바꾸고, C 가 3갈래로 배타인데 판별자가 표에 없는 경우를 다룬다.

#필드타입P값 범위 / 형식
1policy_idintegerM1 이상
2namestringO31바이트 이하
3descstringO63바이트 이하
4priorityintegerO0 ~ 15
5qciintegerO1 ~ 9
6mbr_ul / mbr_dlintegerO0 ~ 10,000,000 (kbps)
7gbr_ul / gbr_dlintegerO0 ~ 10,000,000 (kbps)
8valid_from / valid_tointegerOUNIX epoch.
둘 다 있으면 valid_to >= valid_from
9cap_kbintegerO0 ~ 40억 (KB)
10roamingbooleanO
11kindenumMFLAT / USAGE / TIERED (표에 없으므로 새로 만든다)
12flatobjectCkind == FLAT: fee(0 ~ 40억)
13usageobjectCkind == USAGE: unit_price(0 ~ 40억), unit_kb(1 이상)
14tieredobjectCkind == TIERED: tier_cnt(1 ~ 4), tiers[]: up_to_kb 오름차순, price

함수와 규칙

  1. 선택 필드(#2 ~ #10, 12개)는 uint32_t present 의 비트로 표현한다. 비트 이름은 PB_NAME / PB_DESC / PB_PRIORITY … 로 enum 에 둔다 (매직넘버 금지).
  2. PB_HAS(p, bit) 로 존재 여부를 읽는다.
  3. kind 는 4B 이므로 present 와 같은 4B 그룹에 둔다.
  4. policy_init(p), policy_validate(p) 를 만든다.
  5. policy_set_flat(p, fee), policy_set_usage(p, price, unit_kb), policy_set_tiered(p, tiers, n) 와 getter 3개를 쌍으로 만든다. getter 는 태그가 다르면 NULL 을 돌려준다.
  6. validate 는 present 비트가 켜진 필드만 검사한다. kind 가 정해지지 않았으면 (M 인데 안 채워짐) 반려한다.

에러 코드 규칙 (B)

상황리턴코드
NULL 인자RET_INVALID_ARG
set_tiered 의 n 이 0 또는 4 초과, set_usage 의 unit_kb == 0RET_INVALID_ARG
범위 위반 (priority, qci, valid_to < valid_from, 오름차순 위반 등), kind 미지정RET_BAD_FORMAT

1-C. 변형 C — 로그 질의 결과

결과 행의 개수에 상한이 없고, union arm 안에 포인터가 들어간다. _free() 를 switch (tag) 로 갈라서 해제하는 것이 핵심이다.

#필드타입P값 범위 / 형식
1kindenumMNONE / ROWS / COUNT / ERROR
2rowsobjectCkind == ROWS: char **rows, uint32_t row_cnt.
0 ~ 수만 건, 상한 없음
3countobjectCkind == COUNT: uint64_t total
4errorobjectCkind == ERROR: int32_t code, char msg[64]

함수와 규칙

  1. 구조체 qres_t 는 kind 와 union u { qr_rows_t rows; qr_count_t count; qr_error_t error; } 로 이루어진다.
  2. union 이 포인터(8B) 를 품으므로 union 을 맨 위, kind 를 그 아래에 둔다.
  3. qres_init(q): 전부 0, kind = QR_NONE.
  4. qres_set_rows(q, src, n): 문자열을 깊은 복사한다. n == 0 이면 rows = NULL, row_cnt = 0 으로 두고 malloc(0) 을 하지 않는다.
  5. qres_set_count(q, total), qres_set_error(q, code, msg): msg 는 63자까지 복사하고 널 종단한다 (NULL 이면 빈 문자열).
  6. set 은 이전 arm 을 먼저 해제한다 (누수 없음).
  7. getter 3개는 태그가 다르면 NULL 이다.
  8. qres_free(q): switch (tag) 로 갈라서 해제한다. 해제 뒤 memset(&q->u, 0) + kind = QR_NONE. NULL 을 받아도, 두 번 불러도 안전하다.

에러 코드 규칙 (C)

상황리턴코드
q 가 NULL, src 가 NULL 인데 n > 0RET_INVALID_ARG
할당 실패RET_NO_MEM (실패하면 kind = QR_NONE, 누수 없음)

2. pseudo code 템플릿 — 보고 쳐서 익힌다

사용법 변형별로 아래 pseudo code 를 d2_model/src/*.h / *.c 에 주석만 복사한다. 코드는 주석을 보고 내가 쓴다. 다 쓰면 [ ] 를 x 로 바꾼다. 막히면 §5 모범답안(A)의 해당 단계만 본다.

2-A. subs_type.h / subs_type.c — Level 1 (1회차, 전부 본다)

/* subs_type.h */
/* [ ] 1. 인클루드 가드 SUBS_TYPE_H, include: <stdbool.h> <stdint.h> <time.h> */
/* [ ] 2. 상수: SUPI_LEN_MAX 15 / NICKNAME_LEN_MAX 31 / SD_LEN 6 / NSSAI_NUM_MAX 8 / AMBR_KBPS_MAX 4000000 */
/* [ ] 3. rat_type_t: RAT_UNKNOWN = 0 ... RAT_END   + to_str / from_str 선언 */
/* [ ] 4. snssai_t: sst(M), has_sd + sd[SD_LEN + 1]   (has_ 는 값 바로 위) */
/* [ ] 5. (1차) subs_profile_t — 규격서 표 순서대로
 *        M : supi[+1], suspended
 *        O : nickname, rat_type, ambr_ul, ambr_dl, last_seen   → 각각 has_ 를 값 바로 위
 *        배열 : nssai[8] + nssai_num (개수는 배열 바로 아래)
 *        time_t 는 64비트 (int 금지) */
/* [ ] 6. (손 계산) 오프셋 표를 채우고 예측 sizeof 를 적는다.  ← 코드로 찍기 전에 */
/* [ ] 7. (2차) 재배치
 *        8B (time_t) → 4B (uint32_t, enum) → 1B (bool, has_ 를 한곳에) → char[] / 정렬 1 구조체
 *        짝이 떨어진 has_ 는 그룹 주석에 "어느 값의 것인지" 적는다
 *        _Static_assert(sizeof(subs_profile_t) == 예측값) */
/* [ ] 8. 확장 (C 필드)
 *        charge_method_t (UNKNOWN = 0 / PREPAID / POSTPAID / END)   ← 태그
 *        prepaid_info_t { balance }
 *        postpaid_info_t { billing_day, email_cnt, emails[3][64] }  ← 개수는 이 안에
 *        union info { prepaid; postpaid; }
 *        has_suspend_cause + suspend_cause[64]                      ← union 에 넣지 않는다 (독립 C)
 *        새 sizeof 를 다시 예측 → 실측 → _Static_assert 갱신 */
/* [ ] 9. 함수 선언: init / validate / dump / get_prepaid / get_postpaid / set_prepaid_balance / set_postpaid */
 
/* subs_type.c */
/* [ ] 1. g_rat_str[RAT_END] — 지정 초기화자 ([RAT_NR] = "NR" ...) */
/* [ ] 2. rat_type_to_str: 범위 밖이면 "UNKNOWN" */
/* [ ] 3. rat_type_from_str
 *        가드(s, out) → *out = RAT_UNKNOWN → 표 순회
 *        못 찾아도 에러가 아니다: WRN 로그 후 RET_OK */
/* [ ] 4. subs_profile_init: NULL 이면 return / memset / 기본값(suspended = false) 명시 */
/* [ ] 5. V_FAIL(fmt, ...) 지역 매크로: LOG_ERR 후 RET_BAD_FORMAT 리턴 */
/* [ ] 6. subs_profile_validate (위에서 아래로)
 *        supi      : 1~15자, 숫자만
 *        nssai_num : 1~8, sd 가 있으면 정확히 6자
 *        O 필드    : has_ 가 켜진 것만 범위 검사
 *        C suspend_cause : suspended 이면 has_ + 비어 있지 않음 (없으면 RET_INVALID_ARG)
 *                          아니면서 값이 왔으면 정책 선택 + 근거 주석
 *        charging_method : switch (태그) — default 는 UNKNOWN 을 반려, 가리키는 arm 만 검사 */
/* [ ] 7. subs_profile_dump: O 필드는 없으면 "-" */
/* [ ] 8. set_* : ① 태그 대입 → ② memset(&info) → ③ arm 채우기      (이 순서)
 *        get_* : 태그가 다르면 NULL */

2-B. policy.h / policy.c — Level 2 (2회차, 번호와 제목만 본다)

/* policy.h */
/* [ ] 1. 가드 / include */
/* [ ] 2. 비트 enum: PB_NAME = 1u << 0 ... (12개) + PB_HAS(p, bit) 매크로 */
/* [ ] 3. policy_kind_t: PK_NONE = 0 / FLAT / USAGE / TIERED / END   ← 새로 만든 태그 */
/* [ ] 4. arm 구조체 3개 (tiered 는 tier_cnt + tiers[4] 를 안에 가둔다) */
/* [ ] 5. policy_t: 8B 그룹 → 4B 그룹 (present 와 kind 를 나란히) → 1B → char[] */
/* [ ] 6. _Static_assert 로 크기 고정 */
/* [ ] 7. 함수 선언: init / validate / set_flat / set_usage / set_tiered / get_flat / get_usage / get_tiered */
 
/* policy.c */
/* [ ] 1. policy_init */
/* [ ] 2. validate: present 비트가 켜진 필드만 / valid_to >= valid_from / switch(kind) default 반려 */
/* [ ] 3. set_* 3개: 인자 검사 → 태그 → memset(&u) → 채우기 */
/* [ ] 4. get_* 3개: 태그가 다르면 NULL */

2-C. qres.h / qres.c — Level 3 (3회차, 백지)

/* 아무것도 안 본다.
 * §1-C 요구사항서만 보고 만든다.
 *
 * 막히면 세 가지만 찾아본다.
 *   - union 에 포인터가 들어갈 때 정렬 (맨 위에 두는 이유)
 *   - 문자열 배열 깊은 복사와 해제 순서 (안쪽 → 바깥)
 *   - 태그를 보고 갈라서 해제하는 switch */

막혔을 때 찾는 순서

  1. 컴파일 에러가 나면 에러 문구를 본다. unknown type name / implicit declaration 이면 include 누락이다.
  2. man 3 <함수> 로 SYNOPSIS 맨 위의 #include 를 확인한다.
  3. 로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
  4. 그래도 안 되면 모범답안의 해당 단계 하나만 본다.

2-D. 코드 치기 전에 채우는 판

아래 표들은 코드를 치기 전에 손으로 채운다. (C 판단표는 §1-A 에 있다.)

판단표 — 배열을 고정으로 할까 동적으로 할까

질문예아니오
규격에 상한이 명시돼 있나?→ 고정배열아래로
상한이 있는데 최악치가 크고(수천 이상) 평소엔 몇 개뿐인가?→ 동적→ 고정배열
개수 상한 자체가 없나?→ 동적 (_free() 필수)→ 고정배열

변형 A는 “1~8개” 라고 규격에 적혀 있다 → 고정배열. 고민할 것이 없다.

union 판 — 배타 C 를 만났을 때

질문예아니오
C 필드들이 동시에 존재할 수 있나?→ union ✗ (각자 has_)아래로
어느 것인지 알려 주는 판별자 필드가 이미 있나?→ 그걸 태그로 재사용→ kind enum 을 새로 만든다
arm 하나만 유독 큰가? (나머지의 수십 배)→ 그 arm 만 포인터로 빼고 태그 볼 때 할당→ 그냥 union

타입 고르는 판

규격의 값 범위C 타입
0 ~ 255uint8_t
0 ~ 65535uint16_t
0 ~ 40억uint32_t
시각(epoch)time_t (64비트) — int 로 받으면 2038년에 터진다
문자열 N자char x[N + 1]
참/거짓bool (<stdbool.h>)

정렬 판 — 타입별 정렬과 손 계산표

타입크기 = 정렬 (LP64)
bool, uint8_t, char, char[N]1
uint16_t2
uint32_t, enum4
time_t, uint64_t, 포인터8
중첩 struct안에서 가장 큰 정렬 (snssai_t 는 전부 1바이트 계열이라 1)

규칙: 멤버 오프셋 = 이전 끝 을 자기 정렬의 배수로 올린 값. 마지막에 구조체 전체를 최대 정렬의 배수로 올린다.

오프셋 손 계산표 — 1차(표 순서) 구조체를 위에서부터 채운다. 코드 치기 전에 sizeof 를 적어 둔다.

멤버크기정렬오프셋끝앞 패딩
supi0
⋮
(꼬리)
예측 sizeof합계

예측이 틀렸다면 그게 가장 좋은 학습 지점이다

틀린 이유는 거의 둘 중 하나다. ① char[N] 을 N바이트 정렬이라고 착각 (정렬은 1). ② 꼬리 패딩을 빼먹음 (구조체 전체를 최대 정렬 배수로 올린다). 어느 쪽이었는지 메모해 둔다.


3. 테스트 코드 — 이게 통과하면 끝

d2_model/src/subs_type.h  subs_type.c     변형 A
d2_model/src/policy.h     policy.c        변형 B
d2_model/src/qres.h       qres.c          변형 C
d2_model/test/test_subs_type.c            변형 A 시험 (3-0 매크로 + 3-A + main)
d2_model/test/test_policy.c               변형 B 시험 (3-0 매크로 + 3-B + main)
d2_model/test/test_qres.c                 변형 C 시험 (3-0 매크로 + 3-C + main)

시험의 형태 (D1~D9 공통)

정상 한 가지, 경계 여러 가지, NULL / 실패 경로를 확인한다. D2 는 여기에 레이아웃(정렬) 과 조건부(C) 와 union 시험이 붙는다.

3-0. 공통 매크로

#include <stdio.h>
#include <string.h>
#include "common.h"
#include "log.h"
 
log_level_t g_log_level = LOG_LV_ERR;   /* log.h 의 extern 정의. 시험 파일마다 한 번 */
static int g_fail = 0;
 
#define T_EQ_INT(_got, _exp)                                            \
    do {                                                                \
        long long _g = (long long)(_got), _e = (long long)(_exp);       \
        if (_g != _e) { g_fail++;                                       \
            printf("  X %s:%d  got=%lld exp=%lld\n",                    \
                   __func__, __LINE__, _g, _e); }                       \
    } while (0)
 
#define T_EQ_STR(_got, _exp)                                            \
    do {                                                                \
        if (strcmp((_got), (_exp)) != 0) { g_fail++;                    \
            printf("  X %s:%d  got=\"%s\" exp=\"%s\"\n",                \
                   __func__, __LINE__, (_got), (_exp)); }               \
    } while (0)

g_log_level 정의는 위 매크로 블록에 이미 들어 있다 (시험 파일마다 한 번, D0 참고). LOG_WRN · LOG_INF 를 쓰는 순간 이 정의가 없으면 링크 에러(undefined reference to g_log_level)가 난다.

3-A. 변형 A 시험 — 시험 ①~⑥ + enum 왕복

시험 ①~③ 은 정상 / 경계 / NULL 이고, ④ 레이아웃, ⑤ 조건부(C), ⑥ tagged union, ⑦ enum 왕복이 이어진다.

시험 ①~③ — 정상 / 경계 / NULL

static void test_normal(void)   /* 필수만 채운 최소 레코드가 통과해야 한다 */
{
    subs_profile_t p;
    subs_profile_init(&p);
    snprintf(p.supi, sizeof(p.supi), "450081234567890");
    p.nssai_num       = 1;
    p.nssai[0].sst    = 1;
    subs_profile_set_prepaid_balance(&p, 0);     /* chargingMethod 는 M — 안 정하면 반려된다 */
    T_EQ_INT(subs_profile_validate(&p), RET_OK);
}
 
static void test_edge(void)     /* 경계: 배열 0개 / 9개 / 범위 초과 */
{
    subs_profile_t p;
    subs_profile_init(&p);
    snprintf(p.supi, sizeof(p.supi), "450081234567890");
    subs_profile_set_prepaid_balance(&p, 0);
 
    p.nssai_num = 0;                    /* 규격상 최소 1개 */
    T_EQ_INT(subs_profile_validate(&p), RET_BAD_FORMAT);
 
    p.nssai_num = 9;                    /* 규격상 최대 8개 */
    T_EQ_INT(subs_profile_validate(&p), RET_BAD_FORMAT);
 
    p.nssai_num = 1; p.nssai[0].sst = 255;
    p.has_ambr_ul = true; p.ambr_ul = 4000001;   /* 상한 한 칸 초과 */
    T_EQ_INT(subs_profile_validate(&p), RET_BAD_FORMAT);
}
 
static void test_null(void)
{
    T_EQ_INT(subs_profile_validate(NULL), RET_INVALID_ARG);
    T_EQ_INT(rat_type_from_str(NULL, NULL), RET_INVALID_ARG);
}

시험 ④ — 레이아웃 (정렬)

#include <stddef.h>                    /* offsetof */
#include <stdalign.h>                  /* alignof (C11) */
 
#define FSZ(_t, _m)   sizeof(((_t *)0)->_m)
 
static void test_layout(void)
{
    /* 멤버 크기 합. 멤버를 추가하면 여기도 같이 늘려야 한다 -> 그래서 이 시험이 필요하다 */
    size_t sum = FSZ(subs_profile_t, supi)      + FSZ(subs_profile_t, suspended)
               + FSZ(subs_profile_t, has_nickname) + FSZ(subs_profile_t, nickname)
               + FSZ(subs_profile_t, has_rat_type) + FSZ(subs_profile_t, rat_type)
               + FSZ(subs_profile_t, has_ambr_ul)  + FSZ(subs_profile_t, ambr_ul)
               + FSZ(subs_profile_t, has_ambr_dl)  + FSZ(subs_profile_t, ambr_dl)
               + FSZ(subs_profile_t, has_last_seen)+ FSZ(subs_profile_t, last_seen)
               + FSZ(subs_profile_t, nssai)        + FSZ(subs_profile_t, nssai_num)
               + FSZ(subs_profile_t, has_suspend_cause) + FSZ(subs_profile_t, suspend_cause)
               + FSZ(subs_profile_t, charging_method)   + FSZ(subs_profile_t, info);   /* 확장 필드 */
    size_t pad = sizeof(subs_profile_t) - sum;
 
    /* 구멍이 하나도 없으면 남는 패딩은 꼬리 패딩뿐이고, 꼬리 패딩은 반드시 정렬값보다 작다 */
    T_EQ_INT(pad < alignof(subs_profile_t), 1);
 
    /* 큰 것부터 놓였는지: 8B 멤버가 맨 앞 */
    T_EQ_INT((int)offsetof(subs_profile_t, last_seen), 0);
}

이 시험은 재배치 후에 통과해야 한다

1차(표 순서) 구조체에서는 pad 가 21이라 일부러 실패한다. 실패하는 걸 먼저 눈으로 보고 재배치한다.

시험 ⑤ — 조건부(C) 필드

static void test_conditional(void)
{
    subs_profile_t p;
 
    subs_profile_init(&p);
    snprintf(p.supi, sizeof(p.supi), "450081234567890");
    p.nssai_num = 1; p.nssai[0].sst = 1;
    subs_profile_set_prepaid_balance(&p, 1000);      /* charging_method = PREPAID */
 
    /* ① 조건 참(suspended=true)인데 cause 가 없다 -> 반려 */
    p.suspended = true;
    p.has_suspend_cause = false;
    T_EQ_INT(subs_profile_validate(&p), RET_INVALID_ARG);
 
    /* ② 조건 참 + cause 있음 -> 통과 */
    p.has_suspend_cause = true;
    snprintf(p.suspend_cause, sizeof(p.suspend_cause), "OVERDUE");
    T_EQ_INT(subs_profile_validate(&p), RET_OK);
 
    /* ③ 조건 거짓(suspended=false)인데 cause 가 왔다 -> 내가 정한 정책대로.
     *    거절이면 RET_INVALID_ARG, 무시+경고면 RET_OK. 둘 중 하나로 고정되어 있으면 된다 */
    p.suspended = false;
    T_EQ_INT(subs_profile_validate(&p), RET_OK);     /* 무시 정책을 골랐을 때 */
}

시험 ⑥ — tagged union (제일 잘 틀리는 곳)

static void test_union(void)
{
    subs_profile_t p;
    const postpaid_info_t *pp = NULL;
 
    subs_profile_init(&p);
 
    /* ① 태그가 NONE/PREPAID 인데 postpaid 를 읽으려 하면 NULL 이어야 한다.
     *    여기서 쓰레기 포인터가 나오면 getter 가 태그를 안 보고 있다는 뜻 */
    T_EQ_INT(subs_profile_get_postpaid(&p) == NULL, 1);
 
    /* ② POSTPAID 로 채운다 */
    {
        postpaid_info_t in;
        memset(&in, 0x00, sizeof(in));
        in.billing_day = 25;
        in.email_cnt   = 2;
        snprintf(in.emails[0], sizeof(in.emails[0]), "a@x.com");
        snprintf(in.emails[1], sizeof(in.emails[1]), "b@x.com");
        T_EQ_INT(subs_profile_set_postpaid(&p, &in), RET_OK);
    }
    pp = subs_profile_get_postpaid(&p);
    T_EQ_INT(pp != NULL, 1);
    T_EQ_INT(pp->billing_day, 25);
    T_EQ_INT(pp->email_cnt, 2);
 
    /* ③ ★ 잔재 시험: PREPAID 로 바꾸면 postpaid 는 더 이상 안 보여야 하고,
     *    prepaid 쪽에 이전 바이트가 섞여 들어오면 안 된다.
     *    set 함수가 union 을 memset 하지 않으면 여기서 balance 가 쓰레기가 된다 */
    T_EQ_INT(subs_profile_set_prepaid_balance(&p, 0), RET_OK);
    T_EQ_INT(subs_profile_get_postpaid(&p) == NULL, 1);
    T_EQ_INT(subs_profile_get_prepaid(&p)->balance, 0);
 
    /* ④ 태그가 가리키는 쪽만 validate 가 본다 — postpaid 범위를 어겨도 PREPAID 면 통과 */
    snprintf(p.supi, sizeof(p.supi), "450081234567890");
    p.nssai_num = 1; p.nssai[0].sst = 1;
    T_EQ_INT(subs_profile_validate(&p), RET_OK);
}

③ 이 이 드릴 전체에서 가장 중요한 한 줄이다

set 함수에서 memset(&p->info, 0, sizeof(p->info)) 를 빼먹어도 대부분의 시험은 통과한다. ③ 처럼 작은 멤버로 갈아탄 뒤 읽어봐야 드러난다. 실무에서 이 버그는 “가끔 잔액이 이상한 값” 으로 나타나고, 재현이 안 된다.

시험 ⑦ — enum 왕복

static void test_enum(void)
{
    int i = 0;
 
    /* to_str -> from_str 이 제자리로 돌아와야 한다. UNKNOWN 은 포함, END 는 제외 */
    for (i = RAT_UNKNOWN; i < RAT_END; i++) {
        rat_type_t back = RAT_UNKNOWN;
        T_EQ_INT(rat_type_from_str(rat_type_to_str((rat_type_t)i), &back), RET_OK);
        T_EQ_INT(back, i);
    }
 
    {
        rat_type_t out = RAT_NR;
        T_EQ_INT(rat_type_from_str("6G", &out), RET_OK);   /* 모르는 값은 에러가 아니다 */
        T_EQ_INT(out, RAT_UNKNOWN);
    }
}
 
int main(void)
{
    test_normal();
    test_edge();
    test_null();
    test_layout();
    test_conditional();
    test_union();
    test_enum();
 
    printf(g_fail ? "FAIL %d\n" : "PASS\n", g_fail);
    return g_fail ? 1 : 0;
}

3-B. 변형 B 시험 — policy_*

main() 은 3-A 와 같은 모양이다.

static void test_normal(void)
{
    policy_t p;
    const flat_t *f = NULL;
 
    policy_init(&p);
    p.policy_id = 1;
    T_EQ_INT(policy_set_flat(&p, 1000), RET_OK);
    T_EQ_INT(policy_validate(&p), RET_OK);
    f = policy_get_flat(&p);
    T_EQ_INT(f != NULL, 1);
    T_EQ_INT(f->fee, 1000);
    T_EQ_INT(PB_HAS(&p, PB_NAME), 0);                  /* 아무것도 안 켰다 */
}
 
static void test_edge(void)
{
    policy_t p;
    tier_t   t[4] = { {100, 10}, {200, 20}, {300, 30}, {400, 40} };
 
    policy_init(&p);
    p.policy_id = 1;
    policy_set_flat(&p, 0);
 
    p.present |= PB_PRIORITY;  p.priority = 15;
    T_EQ_INT(policy_validate(&p), RET_OK);             /* 상한 */
    p.priority = 16;
    T_EQ_INT(policy_validate(&p), RET_BAD_FORMAT);     /* 한 칸 초과 */
 
    p.present &= ~PB_PRIORITY;
    T_EQ_INT(policy_validate(&p), RET_OK);             /* ★ 안 온 필드는 검사하지 않는다 */
 
    p.present |= PB_QCI;
    p.qci = 0;   T_EQ_INT(policy_validate(&p), RET_BAD_FORMAT);
    p.qci = 1;   T_EQ_INT(policy_validate(&p), RET_OK);
    p.qci = 9;   T_EQ_INT(policy_validate(&p), RET_OK);
    p.qci = 10;  T_EQ_INT(policy_validate(&p), RET_BAD_FORMAT);
    p.present &= ~PB_QCI;
 
    p.present |= PB_VALID_FROM | PB_VALID_TO;
    p.valid_from = 200;  p.valid_to = 100;
    T_EQ_INT(policy_validate(&p), RET_BAD_FORMAT);     /* to < from */
    p.valid_to = 200;
    T_EQ_INT(policy_validate(&p), RET_OK);             /* 같으면 통과 */
 
    T_EQ_INT(policy_set_tiered(&p, t, 0), RET_INVALID_ARG);
    T_EQ_INT(policy_set_tiered(&p, t, 5), RET_INVALID_ARG);
    T_EQ_INT(policy_set_tiered(&p, t, 4), RET_OK);
    T_EQ_INT(policy_validate(&p), RET_OK);
    t[2].up_to_kb = 50;                                /* 오름차순 위반 */
    T_EQ_INT(policy_set_tiered(&p, t, 4), RET_OK);     /* set 은 저장만 한다 */
    T_EQ_INT(policy_validate(&p), RET_BAD_FORMAT);     /* 판정은 validate */
 
    T_EQ_INT(policy_set_usage(&p, 5, 0), RET_INVALID_ARG);   /* unit_kb == 0 */
 
    policy_init(&p);                                   /* kind 를 안 정한 레코드 */
    p.policy_id = 1;
    T_EQ_INT(policy_validate(&p), RET_BAD_FORMAT);     /* M 인데 안 채워짐 */
}
 
static void test_null(void)
{
    policy_t p;
    tier_t   t[1] = { {1, 1} };
 
    policy_init(&p);
    T_EQ_INT(policy_validate(NULL), RET_INVALID_ARG);
    T_EQ_INT(policy_set_flat(NULL, 1), RET_INVALID_ARG);
    T_EQ_INT(policy_set_usage(NULL, 1, 1), RET_INVALID_ARG);
    T_EQ_INT(policy_set_tiered(NULL, t, 1), RET_INVALID_ARG);
    T_EQ_INT(policy_set_tiered(&p, NULL, 1), RET_INVALID_ARG);
    T_EQ_INT(policy_get_flat(NULL) == NULL, 1);
}
 
static void test_union(void)
{
    policy_t p;
    tier_t   t[4] = { {100, 10}, {200, 20}, {300, 30}, {400, 40} };
 
    policy_init(&p);
    T_EQ_INT(policy_get_flat(&p) == NULL, 1);          /* 태그가 NONE 이면 전부 NULL */
    T_EQ_INT(policy_get_tiered(&p) == NULL, 1);
 
    policy_set_tiered(&p, t, 4);
    T_EQ_INT(policy_get_tiered(&p)->tier_cnt, 4);
    T_EQ_INT(policy_get_flat(&p) == NULL, 1);          /* 다른 arm 은 안 보인다 */
 
    policy_set_flat(&p, 0);                            /* ★ 잔재 시험 */
    T_EQ_INT(policy_get_tiered(&p) == NULL, 1);
    T_EQ_INT(policy_get_flat(&p)->fee, 0);             /* 이전 arm 의 바이트가 섞이면 0 이 아니다 */
 
    policy_set_usage(&p, 7, 1);
    T_EQ_INT(policy_get_usage(&p)->unit_price, 7);
    T_EQ_INT(policy_get_flat(&p) == NULL, 1);
}
 
static void test_layout(void)
{
    T_EQ_INT((int)offsetof(policy_t, valid_from), 0);  /* 8B 그룹이 맨 위 */
    T_EQ_INT((int)offsetof(policy_t, valid_to), 8);
    T_EQ_INT((int)offsetof(policy_t, kind) - (int)offsetof(policy_t, present), 4);  /* 같은 4B 그룹, 나란히 */
}

test_layout 은 <stddef.h> 가 필요하다.

3-C. 변형 C 시험 — qres_*

main() 은 3-A 와 같은 모양이다.

static void test_normal(void)
{
    qres_t q;
    char   a[] = "alpha";
    const char *src[3] = { a, "beta", "gamma" };
    const qr_rows_t *r = NULL;
 
    qres_init(&q);
    T_EQ_INT(qres_set_rows(&q, src, 3), RET_OK);
    r = qres_get_rows(&q);
    T_EQ_INT(r != NULL, 1);
    T_EQ_INT(r->row_cnt, 3);
    T_EQ_STR(r->rows[0], "alpha");
    T_EQ_STR(r->rows[2], "gamma");
 
    a[0] = 'Z';                                        /* ★ 깊은 복사: 원본을 바꿔도 안 변한다 */
    T_EQ_STR(r->rows[0], "alpha");
 
    qres_free(&q);
    T_EQ_INT(q.kind, QR_NONE);
    T_EQ_INT(qres_get_rows(&q) == NULL, 1);
}
 
static void test_edge(void)
{
    qres_t q;
    const char *none[1] = { "x" };
    uint32_t i = 0, n = 100000;                        /* 상한 없음: 수만 건 */
    const char **big = (const char **)calloc(n, sizeof(*big));
    char msg[128];
    const qr_error_t *e = NULL;
 
    qres_init(&q);
 
    T_EQ_INT(qres_set_rows(&q, none, 0), RET_OK);      /* 0건 */
    T_EQ_INT(qres_get_rows(&q) != NULL, 1);
    T_EQ_INT(qres_get_rows(&q)->row_cnt, 0);
    T_EQ_INT(qres_get_rows(&q)->rows == NULL, 1);      /* malloc(0) 을 하지 않는다 */
 
    for (i = 0; i < n; i++) big[i] = "row";
    T_EQ_INT(qres_set_rows(&q, big, n), RET_OK);       /* 대량 */
    T_EQ_INT(qres_get_rows(&q)->row_cnt, n);
    T_EQ_INT(qres_set_rows(&q, big, n), RET_OK);       /* 덮어쓰기: 이전 것을 먼저 해제 (누수 없음) */
    free((void *)big);
 
    T_EQ_INT(qres_set_count(&q, UINT64_MAX), RET_OK);  /* arm 전환: rows 해제 */
    T_EQ_INT(qres_get_rows(&q) == NULL, 1);
    T_EQ_INT(qres_get_count(&q)->total == UINT64_MAX, 1);
 
    memset(msg, 'x', sizeof(msg) - 1);  msg[sizeof(msg) - 1] = '\0';
    T_EQ_INT(qres_set_error(&q, -5, msg), RET_OK);
    e = qres_get_error(&q);
    T_EQ_INT(e->code, -5);
    T_EQ_INT(strlen(e->msg), 63);                      /* 63자까지, 널 종단 */
    T_EQ_INT(qres_set_error(&q, 1, NULL), RET_OK);
    T_EQ_STR(qres_get_error(&q)->msg, "");
 
    qres_free(&q);
    qres_free(&q);                                     /* 두 번 불러도 안전 */
    T_EQ_INT(q.kind, QR_NONE);
}
 
static void test_null(void)
{
    qres_t q;
    const char *src[1] = { "x" };
 
    qres_init(&q);
    T_EQ_INT(qres_set_rows(NULL, src, 1), RET_INVALID_ARG);
    T_EQ_INT(qres_set_rows(&q, NULL, 1), RET_INVALID_ARG);
    T_EQ_INT(qres_set_rows(&q, NULL, 0), RET_OK);      /* n == 0 이면 src 는 안 본다 */
    T_EQ_INT(qres_set_count(NULL, 1), RET_INVALID_ARG);
    T_EQ_INT(qres_set_error(NULL, 1, "x"), RET_INVALID_ARG);
    T_EQ_INT(qres_get_rows(NULL) == NULL, 1);
    qres_free(NULL);                                   /* 죽지 않으면 통과 */
    qres_free(&q);
}
 
static void test_layout(void)
{
    T_EQ_INT((int)offsetof(qres_t, u), 0);             /* 포인터를 품은 union 이 맨 위 */
}

test_layout 은 <stddef.h>, test_edge 는 <stdlib.h> / <stdint.h> 가 필요하다. 누수는 Makefile 의 AddressSanitizer(LeakSanitizer)가 종료 시점에 잡는다.

3-1. 빌드와 실행 (Makefile, gcc)

Makefile 은 README 의 공통 Makefile 을 ~/cdrill/Makefile 로 쓴다.

make D=d2_model V=subs_type      # 변형 A
make D=d2_model V=policy         # 변형 B
make D=d2_model V=qres           # 변형 C
  • 마지막 줄이 PASS 이고 경고 0, 새니타이저 리포트 0 이면 통과다.
  • 변형 A 의 test_layout 은 1차(표 순서) 구조체에서 일부러 실패한다. 실패하는 것을 눈으로 본 뒤 재배치해서 통과시킨다.

4. 자주 하는 실수

실수결과
Optional 인데 has_ 없이 값만 둠”안 온 것”과 “0”을 영영 구분 못 한다
char sd[6] (16진수 6자리)널 자리가 없다. sd[7] 이 맞다
enum 에 _END 가 없음배열 크기·순회 상한을 손으로 적게 되고, 값 추가할 때 반드시 어긋난다
time_t 대신 int / uint32_t2038년 오버플로. 정적분석에서도 바로 지적당한다
개수 필드가 int 이고 음수 검사 없음음수 개수로 루프 → 즉사. uint8_t 로 두면 구조적으로 막힌다
validate 가 RET_FAIL 만 리턴어느 필드가 문제인지 호출부가 모른다
규격서의 C 를 O 로 옮겨 적음필수여야 할 필드가 빠진 요청이 정상 통과한다
동시에 올 수 있는 C 를 union 에 넣음둘째를 채우는 순간 첫째가 에러 없이 지워진다
태그 없는 union읽는 쪽이 어느 arm 인지 알 방법이 없다
setter 에서 memset(&info, 0) 누락이전 arm 의 바이트가 남아 가끔 이상한 값 — 재현이 안 된다
배열 cnt 를 union 바깥에 둠arm 을 바꿔도 개수가 남아 엉뚱한 메모리를 루프한다
union 을 보는 switch 에 default: 없음UNKNOWN 태그가 조용히 통과한다
표 순서 그대로 두고 끝냄has_(1B) 와 uint32_t/time_t 가 번갈아 나와 패딩이 21B 생긴다
char supi[16] 을 앞에 두고 8B 멤버를 뒤에앞 멤버가 홀수 크기라 뒤 멤버가 줄줄이 밀린다. char[] 는 끝
재배치하면서 has_ 를 값에서 떨어뜨리고 주석 없이 둠나중에 짝을 못 찾는다. 그룹 주석으로 관계를 남긴다
sizeof 를 안 찍고 “줄었겠지” 하고 넘어감실제로는 그대로일 수 있다. 찍어서 숫자로 확인
배열 옆 cnt 를 정렬 때문에 멀리 떼어냄snssai_t 는 정렬 1 이라 cnt 를 배열 바로 위에 둬도 패딩이 없다.
떼어낼 이유가 없다
(B) 비트 번호를 숫자로 직접 씀 (present & 4)어느 필드인지 못 읽는다.
PB_PRIORITY 같은 이름 있는 enum 으로 쓴다
(B) present 를 안 보고 값을 검사함안 온 필드가 0 이라 정상 입력이 반려된다
(C) free 가 태그를 안 보고 아무 arm 이나 해제다른 arm 의 정수를 주소로 착각해 크래시한다
(C) set 이 이전 arm 을 안 지우고 덮어씀이전 rows 가 통째로 샌다 (ASan 이 종료 시점에 보고)
(C) n == 0 에서 malloc(0) 결과를 NULL 검사구현마다 NULL 이 올 수 있어 정상 입력이 NO_MEM 이 된다

5. 모범답안 (변형 A)

아래 subs_type.h 는 확장 전(필드 1~8) 기준이다. 확장(9~12)은 C ①~③ 폴드에 있다.


관련 노트